2.4 切片的结构与内存管理
切片是我们日常使用比较多的一个结构,深入的了解它的结构对于我们提高程序性能也有比较大的帮助。
本节我们将针对切片底层结构、扩容机制、底层数组进行讲解。
本节代码存放目录为 lesson4
切片底层结构
我们在使用的时候发现切片与数组很相似,这是由于本身切片的底层其实就是由数组构成的。主要结构如下:
type slice struct {
array unsafe.Pointer
len int
cap int
}
array
:指向底层数组的指针。len
:长度,切片实际存储元素的数量。cap
:容量,切片最大可以存储元素的数量。
从上面的结构可以看出,切片的底层其实也是数组。
当新创建一个切片时,会同步创建一个底层数组,之后在array
中存储底层数组的指针,那么在访问的时候,其实也是访问的最底层的这个数组。
那么切片结构本身在内存底层是怎么表示的呢?由于切片结构本身在底层是一个结构体,所以其实它的内存表示就是按照结构体的方式来进行的。
扩容机制
上文中我们提到了len
长度与cap
容量,长度是比较好理解的,这也是数组中的概念。
那么容量又是什么呢?首先我们回顾一个概念:数组长度是固定的,不可改变;切片的长度是可变的。
那么既然数组不可变化,切片的底层也是数组,又是怎么实现长度可变的呢?这就涉及到了容量的概念。
在Go
语言中,其实是 通过不断的创建底层新数组实现长度可变的。
我们举个例子,比如目前切片的长度是5
,底层数组的长度也是5
,接下来我需要通过append
新添加一个元素,由于底层数组长度不可变,那么要怎么办呢?
在Go
语言中,这时候就会新创建一个数组,将之前的5
个元素拿到新数组,同时将新添加的这个元素也放到新数组,之后更新切片结构的array
指针指向新的数组。
这样操作是很便捷的,但是每次都创建新的数组,如果数据比较多的时候,这个开销也是不小的。
所以Go
语言提出了容量的概念,也就是说:创建新数组的时候,多创建几个空位,那么之后再添加就不用重复的创建新数组了
我们可以通过下面的代码查看:
a := []int{1, 2, 3, 4, 5}
fmt.Printf("切片a长度: %d, 容量: %d\n", len(a), cap(a))
a = append(a, 6)
fmt.Printf("切片a长度: %d, 容量: %d\n", len(a), cap(a))
结果输出如下所示:
切片a长度: 5, 容量: 5
切片a长度: 6, 容量: 10
从上面的示例我们可以看到,当我们创建切片时,由于是知道元素数量的,所以第一次创建的时候容量就是5
。
新添加元素的时候,由于底层数组的长度只有5
,是不够存储的,所以可以看到容量变成了10
。
我们可以验证一下,底层数组长度是否是10
,代码如下:
hdr := (*reflect.SliceHeader)(unsafe.Pointer(&a))
arrayPtr := hdr.Data
array := (*[10]int)(unsafe.Pointer(arrayPtr))
fmt.Println("底层数组: ", array)
结果输出如下所示:
底层数组: &[1 2 3 4 5 6 0 0 0 0]
从上面的输出我们可以看出,扩容后底层数组的长度就是10
,后面的位置则是默认值,也就是还没有使用的。
除了上面说的,还有另一种特殊的情况。如下代码:
b := [5]int{1, 2, 3, 4, 5}
bs := b[1:4]
fmt.Printf("切片bs index 0: %d, 切片bs长度: %d, 容量: %d\n", bs[0], len(bs), cap(bs))
结果输出如下所示:
切片bs index 0: 2, 切片bs长度: 3, 容量: 4
在上面的代码中,我们从数组b
中截取了部分形成了切片bs
,最终输出的长度是3
,容量是4
。
执行上面的代码,最终bs
的元素是:2,3,4
。那么如果根据上面说的,这时候创建了新切片,底层也会创建新的数组,那容量就应该是3
,为什么会是4
呢?
这是因为在这种情况的时候,切片其实并没有创建新的数组,而是指向数组b
的索引1
内存地址的,这时候从索引1
以后一共有1,2,3,4
四个位置,所以容量是4
而不是3
。
这也就是容量的核心概念:切片起始位置到底层数组结束位置的长度。
扩容规则
上面我们讲到了扩容的机制,那么扩容的规则又是怎么样的呢?我们通过下面的代码看一下:
c := []int{1, 2, 3, 4, 5}
fmt.Printf("切片c长度: %d, 容量: %d\n", len(c), cap(c))
c = append(c, 6)
fmt.Printf("切片c长度: %d, 容量: %d\n", len(c), cap(c))
c = append(c, 7)
fmt.Printf("切片c长度: %d, 容量: %d\n", len(c), cap(c))
c = append(c, 8, 9, 10, 11)
fmt.Printf("切片c长度: %d, 容量: %d\n", len(c), cap(c))
结果输出如下所示:
切片c长度: 5, 容量: 5
切片c长度: 6, 容量: 10
切片c长度: 7, 容量: 10
切片c长度: 11, 容量: 20
从上面的结果我们可以看出,在容量不够的时候,都是将容量扩大到两倍
那么是否一直都是这样呢?如果一直这样的话容量就会变得特别大。我们接着看下面的代码:
for i := 0; i < 2000; i++ {
s = append(s, i)
capNew := cap(s)
if capNew != capOld {
fmt.Printf("扩容: 旧容量=%d, 新容量=%d\n", capOld, capNew)
capOld = capNew
}
}
结果输出如下所示:
go1.19
扩容: 旧容量=5, 新容量=10
扩容: 旧容量=10, 新容量=20
扩容: 旧容量=20, 新容量=40
扩容: 旧容量=40, 新容量=80
扩容: 旧容量=80, 新容量=160
扩容: 旧容量=160, 新容量=336
扩容: 旧容量=336, 新容量=672
扩容: 旧容量=672, 新容量=1184
扩容: 旧容量=1184, 新容量=1696
扩容: 旧容量=1696, 新容量=2384
go1.15
扩容: 旧容量=5, 新容量=10
扩容: 旧容量=10, 新容量=20
扩容: 旧容量=20, 新容量=40
扩容: 旧容量=40, 新容量=80
扩容: 旧容量=80, 新容量=160
扩容: 旧容量=160, 新容量=336
扩容: 旧容量=336, 新容量=672
扩容: 旧容量=672, 新容量=1360
扩容: 旧容量=1360, 新容量=1792
扩容: 旧容量=1792, 新容量=2304
从上面的输出我们可以发现,当容量增长到672
以后,就没有按照2
倍的规则进行了,同时使用不同的Go
版本扩容规则也是不一样的。
如果再换不同的Go
语言版本可能输出还会不一样,但是他们是有相同点的:在容量小于1024时,会按照2倍扩展;大于1024时,会根据内存占用、内存对齐等方式平滑扩容
也就是说,当前容量大于1024
后,就不会按照2
倍那样的去扩容了,而是会平滑的扩容,避免扩容过多。
基于扩容机制及规则,那么我们在使用切片的时候,就应该考虑到长度的计算,避免扩容太频繁造成内存的浪费。
小结
本节我们讲解了切片的底层结构、扩容机制及扩容规则,如果感兴趣的话,我们还可以再去看一下它的底层内存表示。
关于本节总结如下:
切片的底层结构是数组
切片通过创建新数组的方式实现长度可变
切片扩容就是创建新数组